Skip to content

test(napi): keep node's stdout in program order in the napi-app fixture - #37199

Open
dylan-conway wants to merge 2 commits into
mainfrom
claude/napi-get-value-string-stdout-order
Open

dylan-conway wants to merge 2 commits into
mainfrom
claude/napi-get-value-string-stdout-order

Conversation

@dylan-conway

@dylan-conway dylan-conway commented Aug 8, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

Fixes the napi_get_value_string_* > behaves like node on edge cases flake in test/napi/napi.test.ts (Linux only, ~20 of the last 80 main builds). This is a bug in the test fixture's reference (Node) side, not in Bun.

The test runs main.js test_get_value_string under Node and Bun with stdout piped and asserts identical output. The fixture prints from two producers on fd 1: JS console.log and the addon's printf inside BlockingStdoutScope. In every failure the diff is the same shape: Bun's output is correctly interleaved, Node's has its console.log lines moved to the very end.

Why Node reorders:

  1. Node's stdout is asynchronous when it is a pipe on POSIX. libuv tries the write synchronously; if write() returns EAGAIN it queues it for EPOLLOUT and every later console.log queues behind it. test_get_value_string is fully synchronous, so the queue only drains after all the addon's printf output has already gone to the fd.
  2. The EAGAIN comes from BlockingStdoutScope. Node and Bun both setvbuf(stdout, NULL, _IONBF, 0) at startup; on glibc a later setvbuf(stdout, NULL, _IOFBF, 8192) keeps the existing 1-byte _shortbuf instead of allocating, so each scope emitted its output as thousands of 1–2 byte write()s. On Linux Bun's child stdout is an AF_UNIX socketpair, where each write is its own skb charged ~700 B against SO_SNDBUF — ~300 undrained tiny writes fill it. The scope's own writes are blocking so they just wait, but the next JS write after the scope restores O_NONBLOCK gets EAGAIN.

strace of a failing Node run (128 concurrent runs on Linux reproduce it ~7% of the time):

write(1, "t", 1) = 1
write(1, "r", 1) = 1
write(1, "ied to fill 0/32 units of buffer"..., 55) = 55
...                                   (10,279 write(1) calls total)
write(1, "utf8\n", 5) = -1 EAGAIN
...
epoll_pwait(15, [{events=EPOLLOUT}], ...) = 1
write(1, "utf8\n", 5) = 5
writev(1, [{"utf16\n"}, {"test napi_get_value_string on ..."}, {"latin1\n"}, {"utf8\n"}, {"utf16\n"}], 5) = 110

Fix: make Node's stdout blocking in main.js (what Node itself does for TTYs and Windows pipes) so JS and native output cannot reorder, and give BlockingStdoutScope a real buffer (61 writes instead of 10,279) and restore _IONBF afterwards. Bun already writes console.log synchronously, which is why its side was always ordered. Test expectations are unchanged.

How did you verify your code works?

Linux x64: 640 concurrent node main.js test_get_value_string runs under Bun.spawn went from 44 reordered to 0; strace shows no EAGAIN and 61 write(1) calls. bun bd test test/napi/napi.test.ts passes the checkSameOutput tests on macOS arm64.


no test proof · iteration 0 · docs-only change; test-proof not applicable

napi.test.ts compares the stdout of `node main.js <test>` and `bun main.js
<test>`. On Linux the node side intermittently emitted its console.log lines
after all of the addon's printf output, failing `napi_get_value_string_* >
behaves like node on edge cases`.

- Node's stdout is asynchronous when it is a pipe on POSIX: once a write()
  returns EAGAIN, libuv queues that console.log and every later one until the
  event loop runs, while printf keeps writing to fd 1 directly. Make node's
  stdout blocking in main.js so JS and native output cannot reorder.
- The EAGAIN came from BlockingStdoutScope: both runtimes start with stdout
  unbuffered, and glibc's setvbuf(stdout, NULL, _IOFBF, 8192) keeps the
  existing 1-byte buffer, so each scope produced thousands of 1–2 byte writes
  that exhausted the AF_UNIX socketpair's send buffer by skb overhead. Give
  it a real buffer and restore _IONBF afterwards.

No-Verification-Needed: test-fixture-only change
@dylan-conway

Copy link
Copy Markdown
Member Author

@robobun adopt

@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Adopted. Verifying the fixture change locally and watching CI (build 90588).

@coderabbitai

coderabbitai Bot commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The NAPI test app now enables blocking POSIX stdout for non-Bun runtimes. BlockingStdoutScope uses an 8192-byte fully buffered stream and restores unbuffered stdout when it is destroyed.

Changes

POSIX stdout ordering

Layer / File(s) Summary
Configure and restore blocking stdout
test/napi/napi-app/main.js, test/napi/napi-app/utils.h
The test app enables blocking stdout on supported POSIX runtimes. BlockingStdoutScope uses a static 8192-byte buffer with fully buffered stdout and restores unbuffered mode on destruction.

Possibly related PRs

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely identifies the main change: preserving Node stdout order in the napi-app fixture.
Description check ✅ Passed The description explains the bug, root cause, fix, and verification results, and includes both required template sections.

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — test-fixture-only flake fix, no runtime or assertion changes.

What was reviewed:

  • main.js: setBlocking(true) is gated to Node with optional chaining, so Bun and Windows paths are unchanged.
  • BlockingStdoutScope: inline static buffer is safe — all ~17 call sites are non-nested stack guards on the JS thread; header inclusion in multiple TUs is handled by inline.
  • fflush now runs before setvbuf (required ordering), and the destructor restores _IONBF to match the runtimes' actual startup state rather than _IOLBF.
Extended reasoning...

Overview

This PR fixes a Linux-only flake in test/napi/napi.test.ts where the Node reference run's stdout gets reordered relative to Bun's. Two files are touched, both in the test fixture: test/napi/napi-app/main.js (forces Node's piped stdout to blocking mode) and test/napi/napi-app/utils.h (gives BlockingStdoutScope a real 8 KiB buffer, reorders fflush before setvbuf, and restores _IONBF on exit). No Bun runtime code and no test assertions are changed.

Security risks

None. The changes are confined to a test fixture addon and its JS driver; they alter fd flags and stdio buffering inside a spawned child process.

Level of scrutiny

Low. This is test-infrastructure only, backed by a detailed root-cause analysis (Node's async-pipe stdout + glibc's _shortbuf fallback for setvbuf(NULL, _IOFBF, ...) after _IONBF) with strace evidence. The fix matches the diagnosis and follows the repo guidance for de-flaking: it preserves the property the assertion protects (identical Node/Bun output) rather than weakening the check.

Other factors

I checked all BlockingStdoutScope call sites (one in get_string_tests.cpp, ~16 in standalone_tests.cpp) — every one is a single stack-scoped guard in a synchronous NAPI callback, so the shared inline static buffer never has overlapping users, and inline avoids ODR issues across the multiple TUs that include utils.h. The fflush→setvbuf reordering is a strict correctness improvement per the C standard. The main.js change uses _handle?.setBlocking?.() so it degrades to a no-op where the internal handle is absent. No prior review comments to address.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — test-fixture-only de-flake with a clear root cause and strace evidence.

What was reviewed:

  • main.js: setBlocking(true) is guarded by !process.isBun and optional-chained on both _handle and setBlocking, so it's a no-op on Bun and on platforms where the API is absent.
  • utils.h: inline static char buffer[8192] is C++17 and ODR-safe across the two TUs that include it; all ~20 BlockingStdoutScope uses are stack-local on the JS thread, never nested/concurrent, so the shared buffer is fine.
  • Constructor now flushes before setvbuf (correct order); destructor restores _IONBF which matches what both runtimes actually set at startup — more correct than the old _IOLBF.
Extended reasoning...

Overview

This PR touches only the NAPI test fixture (test/napi/napi-app/main.js and test/napi/napi-app/utils.h) to fix a Linux-only flake in napi_get_value_string_* > behaves like node on edge cases (~25% of recent main builds). The flake was on the Node reference side of a checkSameOutput comparison, not in Bun. Two changes: (1) force Node's piped stdout to blocking mode so console.log and native printf cannot reorder after an EAGAIN, and (2) give BlockingStdoutScope a real 8 KiB buffer so glibc doesn't fall back to its 1-byte _shortbuf, and restore _IONBF on exit to match both runtimes' startup state.

Security risks

None. This is test-fixture code that runs a local addon under Node/Bun with piped stdio; no untrusted input, no network, no auth.

Level of scrutiny

Low. No production code is touched, no test assertions are weakened or removed, and the change tightens fixture behavior to match the invariant the test already asserts (identical interleaved output). The PR description includes a precise mechanism (Node's async-pipe stdout + glibc setvbuf shortbuf quirk + AF_UNIX skb accounting), an strace showing the 10 279→61 write reduction and the EAGAIN, and a 640-run stress verification going from 44 reordered to 0.

Other factors

I verified that all ~20 uses of BlockingStdoutScope across get_string_tests.cpp and standalone_tests.cpp are stack-local scopes in synchronous NAPI callbacks on the JS thread — never nested or concurrent — so the single inline static buffer is safe. inline static is C++17 (node-gyp's default on modern Node; MSVC target already uses /std:c++20) and gives one definition across TUs. The main.js line is defensively optional-chained and gated on !process.isBun, so it cannot affect the Bun-side output being compared. The constructor's fflush/setvbuf reorder is the correct sequencing (flush pending unbuffered output before switching modes).

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Another occurrence of the same failure, for the record: build 97051 (ubuntu 25.04 aarch64 - test-bun, at 2db003f, passed on retry). Same shape as described above: in the checkSameOutput("test_get_value_string") diff the Node side (Expected) has the test napi_get_value_string on ... / latin1 / utf8 / utf16 lines for the last four strings drained at the very end, after all of the addon's printf output, while the Bun side (Received) is in program order. The first two strings' headers were still in place, consistent with the first EAGAIN landing partway through.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants